Skip to content

exec, optimizer: TPC-H q21 parity (S5): join arena in 2^14-row blocks, 5/4 salted directory, unique-key chains, LatePayloadJoin - #749

Draft
techgopal wants to merge 37 commits into
mainfrom
p2b/perf2-s5-stack
Draft

techgopal wants to merge 37 commits into
mainfrom
p2b/perf2-s5-stack

Conversation

@techgopal

Copy link
Copy Markdown
Contributor

P3 plan row PAR, combined PR (2): S5, the TPC-H q21 parity lever (gap ledger row q21; brief ops/briefs/2026-11-13-parity-1x.md). Built by opus-1 and opus-2, handed to perf-2 at the end of W0, merged with main and finished here.

What it does

  • X3' step 3 option C (hash join arena in blocks). A join arena is made of blocks of 2^14 rows, so a row is row id & mask of block id >> 14, with no chunk table and no search. Build chunks are copied into blocks once, as they come. Finalize slices whole blocks in place and copies only the tails. Each partition's reservation is then trued down to what its arena holds (RowArena::held_bytes).
  • J4. A join table whose every key occurs once ends each chain without reading it (unique tables, grace tables too).
  • 5/4 salted directory. The directory is the next power of two of 5/4 of its keys, at load up to 4/5 (DuckDB's load factor), instead of twice its keys at load up to 1/2. The key count rounds up, so the directory never grows past its reservation.
  • LatePayloadJoin (krishiv.optimizer.latePayloadJoin, default on). A dimension's payload joins above the semi and anti joins that never read it (q21's s_name).
  • Small builds. Before the block cut, a build merges runs of 64 tiny chunks size-tiered. It cuts blocks first, so a run that already fills a block is copied once. Finalize computes each partition's reblock bytes once.

Measured

  • q21 SF1000, the lever's line (perf-1, 2026-11-13, laptop alone, on opus-2's stack 4721175ce):

    • cold reproduce against a fresh DuckDB: 277.0 s = 1.42x (gate run 473.8 s, 2.36x);
    • no spill in the query; peak RSS 18.2 GiB (DuckDB 22.6);
    • kill line (op 4/op 5 spill or > 400 s): passes.
    • Result: results/20261113T100754Z_reproduce_tpch_sf1000.json.
  • Small-build finalize. perf-1's 11-09 SF10 ABBA had found every small build slower: 9 ms became 49-87 ms. Cause, measured with an instrumented build: build chunks of about 20 rows (q10 op 7: 29,359 chunks for 573K rows), walked twice by finalize's accounting before any partition started. Funding waits were zero. Finalize wall after the fix, SF10 warm, 12 threads:

    Build Option C before After Main
    q10, 573K rows 72-89 ms 14.5-16.3 ms 9 ms
    1.1M rows 310-339 ms 24-26 ms
    q09, 435K rows 50-77 ms 17-23 ms
    3.3M rows (whole blocks) 40-42 ms 40-42 ms
  • Still owed, booked with perf-1 (ops/queue-opus-b1.md, B = this branch's head):

    • (a) q18 SF1000 cold, paired with main. One earlier sample was 725 s / 24.6 GiB against main's 654-661 s / 21.5 GiB.
    • (b) q21 SF100 with latePayloadJoin on and off (line: <= 0.95x).
    • (c) SF10 finalize against main.

    Draft until (a) is in.

Review

  • Opus review ops/reviews/2026-11-13-perf-2-s5-stack-diff.md (krishiv-internal): CHANGES_REQUIRED, 1 Important. The Important was the 5/4 sizing rounding down: 1,639 keys doubled the directory with nothing reserved for it. Fixed in 47eb83c, with a test that fails on the old sizing.
  • Re-review ops/reviews/2026-11-14-perf-2-s5-stack-rereview.md: APPROVED. Its M6 (cut before merge) and M1 (a krishiv.deterministicOrder doc sentence) are fixed in the last commit.
  • The earlier part reviews (opus-1's X3' and LatePayloadJoin reviews, 11-07 to 11-09) had Critical/Important findings. All are fixed at HEAD.
  • Open minors, not blocking:
    • M3: an idle spill keeps the copied tail bytes reserved until the build drops. This over-reserves, the safe direction.
    • M2: no test near load 4/5; no recorded per-commit probe_table_ns A/B for the 5/4 index.
    • M5: LatePayloadJoin does not handle join hints, build-side reorientation turned off, or sort-merge joins. Follow-up.

Verification

  • krishiv-exec + krishiv-session 3388/3388 at 8739b40c8, krishiv-exec + krishiv-common 1260/1260 at 2dd5875e3 (local); krishiv-common + krishiv-optimizer 341/341 after the merge.
  • clippy (-D warnings) and deny clean (pre-commit).
  • Snapshots: the q18 and q21 plan snapshots follow LatePayloadJoin.

…s by hash/key/payload, index bytes) in EXPLAIN ANALYZE and PROFILE (X3' step 1)

Signed-off-by: gopal tathe <44522021+techgopal@users.noreply.github.com>
…; a test of the build memory counters; snapshots (X3' step 1)

Signed-off-by: gopal tathe <44522021+techgopal@users.noreply.github.com>
…t they cover (review nits)

Signed-off-by: gopal tathe <44522021+techgopal@users.noreply.github.com>
…nter test asserts the build rows exactly and bounds the hash bytes (review of #586)

Signed-off-by: gopal tathe <44522021+techgopal@users.noreply.github.com>
Signed-off-by: gopal tathe <44522021+techgopal@users.noreply.github.com>

# Conflicts:
#	docs/reference/explain-analyze.md
…nation copy at finalize or grace reload; a per-row chunk table for the probe's lookups (X3' step 3, option B)

Signed-off-by: gopal tathe <44522021+techgopal@users.noreply.github.com>
…ALYZE snapshots show the build counters; grace.rs file-size line

Signed-off-by: gopal tathe <44522021+techgopal@users.noreply.github.com>
… only; max_finalize_copy_bytes dropped); a test that a chunked arena reads and indexes as one batch, past the 256-chunk table too

Signed-off-by: gopal tathe <44522021+techgopal@users.noreply.github.com>
… per 64 rows, in cache) instead of a byte per row; an arena of no rows keeps an empty chunk, so an empty grace build partition still builds; a joined grace partition keeps its build part's reservations (review of X3' step 3)

Signed-off-by: gopal tathe <44522021+techgopal@users.noreply.github.com>
…w is row id & mask of block id >> 14, with no chunk table or search; build chunks are copied once into blocks as they come, a grace reload blocks its frames as it reads them, finalize slices whole blocks in place and copies only the tails (reserved; the tails beside chunks that live on stay reserved)

Study 10 §4: option B's dependent chunk-table load before each row fired the probe_table +20% kill line. The tiered coalesce stays for the probe side; both move to join/block.rs (grace.rs under its file-size line). An arena's arrow key bytes count each block's own range. Opus review ops/reviews/2026-11-07-opus-1-x3-option-c-diff.md, fixes listed.

Signed-off-by: gopal tathe <44522021+techgopal@users.noreply.github.com>
…ng build reserves every index and the largest copy, not every copy at once); the blocked-arena test gets a tail beside its blocks

Signed-off-by: gopal tathe <44522021+techgopal@users.noreply.github.com>
… so the copy it holds for one partition at a time funds transient copies only (kept tails no longer use it up and leave a later partition unfunded); a test over chunks with half-block tails under tight limits

Signed-off-by: gopal tathe <44522021+techgopal@users.noreply.github.com>
…t reading it (J4, study 15; DuckDB's chains_longer_than_one): the probe and mark_chain skip next() on unique tables, grace tables too

Signed-off-by: gopal tathe <44522021+techgopal@users.noreply.github.com>
…1 §4.4, option C's block slack): the chunks were reserved at their allocated size, builder slack and tails charged at the chunk they were sliced from; finalize copies the tails into exact blocks and drops the chunks, then trues each partition's reservation down to the arena's buffers (RowArena::held_bytes, each buffer's capacity counted once)

A build of 256-row batches over 40,000 keys kept 3.24 MB reserved for a
2.40 MB arena (+35%); trued up it keeps <= 5% over (test
a_built_table_keeps_reserved_what_its_arena_holds). Study 21 measured
~27 MB of such slack per chunked build at 12 threads on q21 SF10 (query
peak +17% against main, a spill at 300 MiB where main has none).

Signed-off-by: gopal tathe <44522021+techgopal@users.noreply.github.com>
…he kept tail bytes it took (the trim leaves them to the chunks' reservation); the test bounds steady from below too; held_bytes sized and documented

Review: ops/reviews/2026-11-08-opus-1-x3-arena-trueup-diff.md (Opus, approve with nits).
Signed-off-by: gopal tathe <44522021+techgopal@users.noreply.github.com>
…keys, at load up to 4/5 (study 21 §5-6, S5 lever 3; DuckDB's load factor), instead of twice its keys at load up to 1/2

next_pow2(2 x keys) landed at 3.7 slots per key on q21's 145 M-key build at
SF1000 (5.1 GB, a third of op 6's held table); at 5/4 it saves 2.5 GB on op 6
and 1.3 GB on op 5 and op 4 there, and nothing at SF10, where the rounding
lands on the same power. The 16-bit salt keeps a probe from reading a row
for all but 1 in 65,536 of the slots it passes; the probe cost at the higher
load is what perf-1's A/B against p2b/opus1-x3-copy-free decides (study 15
measured max_probe_length 2 at load 0.35).

Signed-off-by: gopal tathe <44522021+techgopal@users.noreply.github.com>
…i joins that never read it (LatePayloadJoin, P2b S5 lever 2; q21's s_name)

TPC-H q21 joins supplier ⋈ nation('SAUDI ARABIA') to l1 first and then
carries s_name (31 of the 79 B of arena a build row) through the join with
orders, the EXISTS semi join and the NOT EXISTS anti join, all built on the
l1 side; only the aggregate on top reads it (study 21 §4.1, §6). The new
rule (late_payload.rs) moves such an inner equi-join to the top of its path
and leaves a key-only semi join in its place:

  Consumer(Path(X ⋈[x = d] D))
    => Consumer(Project[out(Path)](Path'(X ⋉[x = d'] Shift(π_d D)) ⋈[x = d] D))

Exact with no uniqueness assumption: every admitted path node (filters,
projections, inner joins either side, left semi/anti/existence/outer joins
with the path on the left, right outer on the right; none reading the
dimension's columns, all deterministic) commutes with an inner join on
columns it does not read, so each X row still meets every matching D row;
the semi join drops only rows the top join drops. D is computed twice (the
semi join's instance under a Shift, as restrict does), so it must be
restrict::repeatable.

Applies only with at least one non-inner join on the path (join ordering
never moves an inner join back across one, so the plan stays a fixed
point), when no other parent reads the join or a path node (no copied
shared subtree), and with statistics on both join sides (never on default
row counts): the carried payload is variable-width or >= 16 B a row, the
dimension estimates no more rows than the other side and scans no more
rows than it holds (it is computed twice), and some join on the path builds
the path's side as the physical planner orients it (an inner join's right
input; the left input of a semi, non-null-aware anti or left outer join
whose right input costs more than 2x and is not broadcast, orient.rs; the
broadcast threshold is the session's). A payload on a probe side only
streams. Runs after Prune/Tidy, so the payload is what the path really
carries; when it moves a join, join order, eager aggregation and the
prune/tidy loop (now prune_and_tidy) run again. Several dimensions under one
consumer all move in one pass. EXPLAIN VERBOSE notes each move and each
refused gate, once each (LatePayloadJoin).

Switch: krishiv.optimizer.latePayloadJoin (session, user, default true),
Options::late_payload_join. Design: krishiv-internal
design/P2b-S5-dimension-payload-late.md.

Plans: TPC-H with the statistics fixture changes q21 (s_name) and q18
(c_name leaves the orders build of the join with lineitem; q18's SF1000
row is the one that spills); TPC-DS with SF1 row counts changes q35, q58,
q72 and q83 (listed in a snapshot); without statistics nothing changes.

Tests: optimizer tests/it/late_payload.rs: q21 and q18 move and nothing
else in TPC-H does; repeating dimension keys (nation on n_regionkey, five
rows a key) under semi, anti and projection consumers and NULL keys; two
dimensions under one consumer; a left outer path; refusals (payload or
dimension key read by the semi join, a nondeterministic dimension, an 8 B
payload, the larger dimension, a broadcast semi side, an existence join, a
right outer join with the path on the right, an all-inner path, default row
counts); the switch by option and config; each checked against the
reference interpreter and for the fixed point; TPC-DS with row counts is a
fixed point. exec tests/planner/late_payload.rs: q21, q18 and two
repeating-key queries at SF0.01 (broadcast off, so the rule fires) return
the rows of the same plan with the rule off. common: the key's default and
parsing.

Review: krishiv-internal ops/reviews/2026-11-09-opus-1-late-payload-diff.md
(Reviewer: Opus; request changes, fixed: the orient-matching build gate,
the executor test against the rule off, TPC-DS with statistics, the missing
refusals and NULL keys, the D cost bound, shared path nodes, the prune/tidy
loop, module order).

Signed-off-by: gopal tathe <44522021+techgopal@users.noreply.github.com>
…s note, join_settings keeps its doc; q18's EXPLAIN ANALYZE snapshot

Refusal notes are still written once; two moves with the same keys each
get their line. The q18 EXPLAIN ANALYZE snapshot (session
explain_analyze_tpch) takes the plan the rule gives it (c_name joins above
the IN semi join).

Review: krishiv-internal ops/reviews/2026-11-09-opus-1-late-payload-diff.md
(re-check, Reviewer: Opus, approve with nits).

Signed-off-by: gopal tathe <44522021+techgopal@users.noreply.github.com>
… joins above the IN semi join)

Signed-off-by: gopal tathe <44522021+techgopal@users.noreply.github.com>
…arena block by block; main's tiered chunk merge lives in block.rs

Signed-off-by: gopal tathe <44522021+techgopal@users.noreply.github.com>
…stack: the rule re-wired into main's pipeline (prune_and_tidy, eager_pass), its config entry in the split registry, q18/q21 snapshots regenerated

Signed-off-by: gopal tathe <44522021+techgopal@users.noreply.github.com>
Signed-off-by: gopal tathe <44522021+techgopal@users.noreply.github.com>
Signed-off-by: gopal tathe <44522021+techgopal@users.noreply.github.com>

# Conflicts:
#	crates/krishiv-common/src/config/registry.rs
#	crates/krishiv-common/src/config/tests.rs
…r grows past its reservation

salted_slots rounded 5/4 of the key count down. Where n + n/4 lands
exactly on a power of two and n is not a multiple of 4 (1,639 keys:
1,639 + 409 = 2,048 slots), the last key failed the load-4/5 check and
the directory doubled while being built, past the index bytes reserved
for it (grace loads and the finalize true-up both assume it never grows).
Now n + ceil(n/4): 5n <= 4 slots always holds. A test builds 1,639 and
neighbouring key counts and checks the directory keeps its planned size
(it fails on the old sizing). Comments that still assumed load 1/2 say
which measurements were taken at it.

Opus review of the S5 stack: ops/reviews/2026-11-13-perf-2-s5-stack-diff.md
(Important finding).

Signed-off-by: gopal tathe <44522021+techgopal@users.noreply.github.com>
…nalize of a small build no longer walks thousands

Option C replaced the build side's size-tiered coalesce with the block
cut, which merges chunks only once a trailing run holds a block's 2^14
rows. A build fed small batches pushes chunks of a few rows (12 drivers
cutting each batch into its partitions): TPC-H q10 at SF10 held 29,359
chunks for 573,157 rows and a 1.1M-row build 115,394. Finalize walked
every one twice for its reservation (RowArena::reblock_bytes) and then
concatenated them, serially before any partition started: finalize took
72-89 ms where main took 9 (perf-1's 11-09 ABBA line: "every small build
slower", the open item before option C's PR).

Part::block now first merges runs of 64 chunks shorter than a block,
size-tiered among themselves (the probe side's coalesce rule, fan-out 64,
chunks of a block or more never merged again; chunks big enough to fill a
block before 64 are held are still copied once, by the cut). Finalize
also computes each partition's reblock bytes once, not twice.

Measured on the laptop, SF10 warm PROFILE, 12 threads, finalize wall per
build (instrumented scratch build of this code, not committed), option C
before -> after: q10's 573K-row build 72-89 -> 14.5-16.3 ms (chunks
29,359 -> about 5,000; main was 9), the 1.1M-row build 310-339 -> 24-26
ms, q09's 435K-row build 50-77 -> 17-23 ms, its 109K-row build 10-11 ->
8-9 ms; the 3.3M-row build (whole blocks) unchanged at 40-42 ms.
A test pushes 5,000 chunks of
20 rows and checks fewer than 128 are held, rows in order, the
reservation covering them.

Signed-off-by: gopal tathe <44522021+techgopal@users.noreply.github.com>
…; deterministicOrder names the build's block copy

Re-review M6 of the S5 stack: Part::block merged runs of 64 short chunks
before the block cut, so a run that already filled a block (63 chunks of
255 rows, then 400) became one 16,465-row chunk: its rows were copied
twice and an 81-row tail stayed beside it. The cut now runs first. A test
checks that run gives a block and an 81-row tail.

M1: the krishiv.deterministicOrder doc says a hash join build's block copy
under memory pressure can change the order of what it returns, as a spill
can.

Review: ops/reviews/2026-11-14-perf-2-s5-stack-rereview.md (APPROVED).
Signed-off-by: gopal tathe <44522021+techgopal@users.noreply.github.com>
Signed-off-by: gopal tathe <44522021+techgopal@users.noreply.github.com>
@coderabbitai

coderabbitai Bot commented Nov 14, 2026

Copy link
Copy Markdown

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

Share

Signed-off-by: gopal tathe <44522021+techgopal@users.noreply.github.com>

# Conflicts:
#	crates/krishiv-common/src/config/tests.rs
@codecov

codecov Bot commented Nov 14, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@codspeed

codspeed Bot commented Nov 14, 2026 •

Copy link
Copy Markdown

Merging this PR will improve performance by 47.47%

1 warning

⚠️ 26 benchmarks are dominated by system calls

System calls cannot be simulated consistently and are left out of the measure. The reported value understates the real cost. Switch to the Walltime instrument to measure them.

Learn more about system calls

See affected benchmarks

⚡ 5 improved benchmarks
✅ 219 untouched benchmarks
⏩ 5 skipped benchmarks1

Performance Changes

Benchmark BASE HEAD Efficiency
⚡ grace_spill_256k_rows_row_layout 3.9 s 1.2 s ×3.1
⚡ grace_spill_256k_rows 5.5 s 4.2 s +32.4%
⚡ build_1m_rows_row_layout 1,083.9 ms 838 ms +29.34%
⚡ build_1m_rows 1,141 ms 987.9 ms +15.5%
⚡ build_1m_rows_runtime_filter 1.3 s 1.2 s +12.9%

Tip

Curious why performance improved? Comment @codspeedbot explain why performance improved on this PR, or directly use the CodSpeed MCP with your agent.


Comparing p2b/perf2-s5-stack (2d7279c) with main (6f40584)

Open in CodSpeed

Footnotes

  1. 5 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports. ↩

block_chunks built a block from the trailing run's chunks up to the one
that fills it, then truncated the list at the run's start: every chunk
past that one was dropped with it. A cut skipped while its copy could not
be reserved, then more chunks pushed (a spilling build, a grace reload
under pressure) left such chunks, and their rows were lost: the
bench-runner spill_equivalence and q21-shaped memory_bound tests returned
fewer rows (option C, ab45aa5, found by bisecting #749's CI failure).
The chunks past the block now stay, after its tail. A test cuts three
chunks of 10,000 rows at once and checks every row is kept.

Signed-off-by: gopal tathe <44522021+techgopal@users.noreply.github.com>
…r join

The rule read every inner join under every consumer as a candidate
(output resolution, a repeatable walk, the path checks) and only then
refused those with no non-inner join on their path: a plan of inner joins
paid for all of it and moved nothing (CodSpeed on #749: join_chain_12
+23-30%, optimize_tpch_22 +11%). walk now carries whether its path holds a
non-inner join and collects candidates only then; what moves is the same.

Signed-off-by: gopal tathe <44522021+techgopal@users.noreply.github.com>
@techgopal

Copy link
Copy Markdown
Contributor Author

CI on 64642790c failed for a real correctness bug, now fixed on 17a690ae2.

test / diff-coverage: spill_equivalence::spilled_results_equal_in_memory_results (count 168,522 vs 176,827) and memory_bound::a_q21_shaped_spilling_join_stays_within_the_bound lost build rows under spill. Bisected (first-parent, the two tests) to option C ab45aa524; main and the stack's merge base pass. Cause: block_chunks built a block from the trailing run up to the chunk that fills it, then truncate(from) dropped every chunk past that one too. Such chunks exist once a cut was skipped (its copy could not be reserved) and more chunks arrived, i.e. a spilling build or a grace reload under pressure. Fix e04bda808 keeps them after the block's tail, plus a unit test (chunks_past_the_block_are_kept). Red/green: on e04bda808 with only the fix lines removed, all three tests fail; on the head, exec + optimizer + bench-runner + session pass 3688/3688.

CodSpeed: the optimizer regressions (join_chain_*/join_star_* +17-30%, optimize_tpch_22 +11%) came from LatePayloadJoin reading every inner join as a candidate before refusing it for having no non-inner join on its path. 17a690ae2 collects candidates only below a non-inner join; what moves is unchanged. The grace_spill_256k_rows* "improvements" were most likely the lost rows; expect them to shrink.

perf-1's S5 timed runs are on hold until they re-run on this head (ops/queue-opus-b1.md).

Signed-off-by: gopal tathe <44522021+techgopal@users.noreply.github.com>
Conflicts: the optimizer config registry and imports (latePayloadJoin
beside #734's groupKeyCode keys), the exec and optimizer CLAUDE.md maps;
both sides kept. join/build.rs merged cleanly: the partition reservations
S5 trues down at the seal now travel with #734's Level0 tables.

The LatePayloadJoin tests switch the group-key code (D2) off: without a
session configuration `Options::default()` runs it, and it coded the
dimension keys those tests move as payload. Sessions run D2 only with
krishiv.optimizer.groupKeyCode on (off by default).

Signed-off-by: gopal tathe <44522021+techgopal@users.noreply.github.com>
@techgopal

Copy link
Copy Markdown
Contributor Author

Merged main b0b214ecb (#734) in 82f87f8db. Conflicts were the optimizer config registry, imports and the crate maps; both sides kept. join/build.rs merged cleanly: the partition reservations S5 trues down at the seal now travel with #734's Level0 tables. Three LatePayloadJoin tests now switch D2 off, because Options::default() runs the group-key code when no session config is given (sessions run it only with krishiv.optimizer.groupKeyCode on). exec, optimizer, session, common and bench-runner tests pass locally. Still a draft until perf-1's q18 SF1000 pair; B is now 82f87f8db.

Signed-off-by: gopal tathe <44522021+techgopal@users.noreply.github.com>

# Conflicts:
#	crates/krishiv-common/src/config/tests.rs
@techgopal

Copy link
Copy Markdown
Contributor Author

main merged in for the CI disk fix (#776); lane: git pull before your next push

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant